Micron Document
🎖️GitЯра🎖️

Commit fa6a21e9979822bcc58b5d475ea9858f85a5c461


Parents : 6d67fb1
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-13T18:45:33Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-13T18:45:33Z

fix(test): stop leaked coroutine scopes poisoning tests (#6683)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
index 587422d508..49c3b5ceb8 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
@@ -20,6 +20,7 @@ import android.app.Application
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.os.Build
+import androidx.annotation.VisibleForTesting
import androidx.collection.intSetOf
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.work.Configuration
@@ -30,6 +31,7 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import coil3.ImageLoader
import coil3.SingletonImageLoader
+import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -68,7 +70,14 @@ open class MeshUtilApplication :
Configuration.Provider,
SingletonImageLoader.Factory {
- protected val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ // SupervisorJob alone only isolates siblings: a failed child still reaches the global uncaught
+ // handler. These launches are best-effort background init, so report rather than escalate.
+ private val applicationScopeExceptionHandler = CoroutineExceptionHandler { context, throwable ->
+ Logger.e(throwable) { "Background application init failed in $context" }
+ }
+
+ protected val applicationScope =
+ CoroutineScope(SupervisorJob() + Dispatchers.Default + applicationScopeExceptionHandler)
/** Supplies Coil's process-wide loader without retaining an Activity in its singleton factory. */
override fun newImageLoader(context: Context): ImageLoader = get<ImageLoader>()
@@ -144,10 +153,19 @@ open class MeshUtilApplication :
}
}
+ /**
+ * Stops the background init launched by [onCreate]. Robolectric never calls [onTerminate], so a unit test that
+ * boots this Application must call this itself — otherwise those jobs outlive the test on real
+ * [Dispatchers.Default] threads and their failures surface against whichever test is running next.
+ */
+ @VisibleForTesting
+ fun cancelBackgroundInit() {
+ applicationScope.cancel()
+ }
+
override fun onTerminate() {
- // Robolectric never calls this, so unit tests booting this Application cannot rely on it.
// cancel() not cancelAndJoin(): joining under runBlocking on the main thread can deadlock.
- applicationScope.cancel()
+ cancelBackgroundInit()
try {
runBlocking { get<DatabaseManager>().close() }
} finally {

diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt
index 77d17c4d11..44522659aa 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt
@@ -33,7 +33,12 @@ import kotlin.test.assertSame
class CoilImageLoaderLifecycleTest {
@After
@OptIn(DelicateCoilApi::class)
- fun tearDown() = SingletonImageLoader.reset()
+ fun tearDown() {
+ // Booting the real Application starts background init on Dispatchers.Default; leaving it running
+ // leaks failures into later tests in this JVM (Robolectric never calls onTerminate).
+ ApplicationProvider.getApplicationContext<MeshUtilApplication>().cancelBackgroundInit()
+ SingletonImageLoader.reset()
+ }
@Test
fun productionApplicationProvidesConfiguredKoinImageLoader() {

diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt
index ae2b417268..e9a3c137a6 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt
@@ -18,6 +18,8 @@ package org.meshtastic.app
import android.app.PendingIntent
import android.content.Intent
+import androidx.test.core.app.ApplicationProvider
+import org.junit.After
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.ContactsRoute
@@ -35,6 +37,13 @@ import kotlin.test.assertNull
@Config(sdk = [34])
class ShareMessageDeepLinkTest {
+ @After
+ fun tearDown() {
+ // No `application =` override, so Robolectric boots the manifest's real MeshUtilApplication and its
+ // background init; stop it here or its failures land on whichever test runs next in this JVM.
+ ApplicationProvider.getApplicationContext<MeshUtilApplication>().cancelBackgroundInit()
+ }
+
@Test
fun `shared text round trips through the deep link query`() {
val messages =

diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
index 6f51a16ce5..35997720cf 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
@@ -127,7 +127,9 @@ internal fun Project.configureTestOptions() {
extensions.findByType(DevelocityTestConfiguration::class.java)?.testRetry {
maxRetries.set(MAX_TEST_RETRIES)
maxFailures.set(MAX_TEST_FAILURES)
- failOnPassedAfterRetry.set(false)
+ // Retry still isolates an ordering flake to one worker, but the build must not report success:
+ // a green tick over a recorded <failure> hid a real cross-test coroutine leak for weeks.
+ failOnPassedAfterRetry.set(true)
}
}
}

diff --git a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
index 7f27f36293..52726b136c 100644
--- a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
+++ b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
@@ -90,6 +90,8 @@ class AndroidScannerViewModelBondingTest {
@AfterTest
fun tearDown() {
+ // Order matters: the ViewModel's coroutines must be gone before Main is unset.
+ harness.clearViewModel(viewModel)
Dispatchers.resetMain()
}

diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
index 51797f7ac6..7d841bd232 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
@@ -16,6 +16,8 @@
*/
package org.meshtastic.feature.connections
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
@@ -23,6 +25,7 @@ import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -136,6 +139,18 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
bleScanner = bleScanner,
)
+ /**
+ * Ends [viewModel]'s lifetime. Call from `@AfterTest` **before** `Dispatchers.resetMain()`.
+ *
+ * `viewModelScope` is never cleared for a hand-built ViewModel, so without this its coroutines outlive the test.
+ * One suspended on a real-dispatcher result (compose-resources resolves on an internal `Dispatchers.Default` scope)
+ * then resumes onto a `Dispatchers.Main` that `resetMain()` has already unset, which throws. Nothing handles it, so
+ * it lands as `UncaughtExceptionsBeforeTest` on whichever test starts next.
+ */
+ fun clearViewModel(viewModel: ViewModel) {
+ viewModel.viewModelScope.cancel()
+ }
+
companion object {
/** A scanned-but-unbonded BLE entry — the input that routes through `requestBonding`. */
fun unbondedBleEntry(address: String, name: String = "Node"): DeviceListEntry.Ble =

diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
index c94867a839..dc8c7b215b 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
@@ -90,6 +90,8 @@ class ScannerViewModelTest {
@AfterTest
fun tearDown() {
+ // Order matters: the ViewModel's coroutines must be gone before Main is unset.
+ harness.clearViewModel(viewModel)
Dispatchers.resetMain()
}

Served by rngit 1.5.0 - Generated in 0.11s